</> 技術筆記Tech Notes

Raspberry Pi 5 (Ubuntu 25.04) 的自動化備份腳本

本文旨在建立 Raspberry Pi 5 的自動化備份腳本,可將資料備份至遠端的 NAS (網路附加儲存)。其設計包含多個模組化腳本,並整合了特定的安全措施。

腳本的安全性設計

此備份腳本在設計上整合了幾項安全考量:

  1. 專用使用者 腳本會建立一個名為 backup-runner 的系統使用者,其 Shell 設定為 /usr/sbin/nologin,限制了此帳號的互動式登入能力。備份任務均由此使用者身份執行。

  2. sudo 權限限制 透過在 /etc/sudoers.d/ 目錄下建立設定檔,腳本為 backup-runner 使用者提供了執行特定指令的權限,而非完整的 root 權限。這遵循了最小權限原則。

  3. 憑證檔案隔離 NAS 的登入憑證被儲存在 /etc/samba/nas-credentials 檔案中,權限設定為 600,僅供 root 使用者讀取。主腳本透過此檔案間接取得憑證。

  4. SMB 傳輸加密 掛載遠端分享時,腳本優先嘗試 SMB 3.x 協議並啟用 seal 選項,該選項用於加密傳輸中的資料封包。

  5. 可選的檔案加密 腳本支援使用 age 工具進行檔案加密。若使用者提供 age 公鑰,備份檔在寫入 NAS 前會先經過加密處理。

備份工作流程

備份流程由主腳本 master-backup.sh 進行協調,它會呼叫其他輔助腳本來完成掛載、打包、清理等各項子任務。以執行應用程式備份 (master-backup.sh app) 為例,其標準執行流程如下:

sequenceDiagram
    participant Cron as Cron/User
    participant Master as master-backup.sh
    participant Mount as backup-mount
    participant AppDumps as backup-app-dumps
    participant Pack as backup-pack
    participant NAS as NAS/CIFS Share
    participant Notify as Notification Service

    Cron->>Master: 執行 `master-backup.sh app`
    Master->>Mount: (sudo) 請求掛載 NAS
    Mount-->>NAS: 嘗試以 SMB 3.1.1 + seal 掛載
    NAS-->>Mount: 掛載操作完成
    Mount-->>Master: 回報掛載結果
    Master->>AppDumps: (sudo) 請求備份應用程式資料
    AppDumps->>AppDumps: 執行 pg_dumpall, mysqldump 等資料庫匯出指令
    AppDumps->>AppDumps: 將匯出檔案儲存至 /var/tmp/backup-staging/ 的臨時目錄
    AppDumps->>Pack: (sudo) 使用 dir 模式請求打包臨時目錄
    Pack->>Pack: 使用 `tar` 指令壓縮臨時目錄
    Pack->>Pack: (若已設定) 使用 `age` 加密 .tar.gz 檔案
    Pack->>NAS: 將最終備份檔 (.tar.gz 或 .tar.gz.age) 移動至 NAS
    NAS-->>Pack: 寫入操作完成
    Pack-->>AppDumps: 回傳最終檔案路徑
    AppDumps-->>Master: 回傳備份結果
    Master->>Master: 呼叫 cleanup_old_backups 函數
    Master->>NAS: 根據 APP_RETENTION 設定清理舊備份檔
    Master->>Notify: (若已設定) 發送執行結果通知
    Notify-->>Notify: 經由 Telegram API 或 ssmtp/msmtp 發送訊息
    Master->>Mount: (經由 trap EXIT) 確保卸載 NAS
    Mount-->>NAS: 執行 umount
    Master-->>Cron: 執行結束

主要功能模組

1. 掛載邏輯 (backup-mount)

此腳本處理掛載遠端 CIFS/SMB 分享的邏輯。它會遍歷 SMB_VERSION_LIST 變數中指定的協議版本(預設 3.1.1,3.0,2.1,2.0),並優先嘗試加密掛載。若 REQUIRE_SMB_ENCRYPTION 變數設為 false,在加密掛載失敗後,會繼續嘗試非加密的掛載方式。

2. 應用程式資料匯出 (backup-app-dumps)

對於資料庫類型的應用,直接複製檔案可能導致資料不一致。此腳本的設計是為了處理這種情況。它會檢查系統中是否存在如 Gitea、PostgreSQL、MariaDB/MySQL 等服務的指令,若存在,則呼叫對應的備份工具(如 gitea dump, pg_dumpall, mysqldump)來產生一致性的資料匯出檔。所有匯出的檔案會被放置在一個臨時目錄中,以待後續打包。

3. 核心封裝程序 (backup-pack)

此腳本是執行動檔案打包和壓縮的核心。它主要執行以下操作:

  • 使用 tar 進行封裝,並包含 --numeric-owner, --acls, --xattrs 等參數以保留檔案的元資料。

  • 使用 gzip 進行壓縮。

  • 根據 AGE_RECIPIENT 環境變數是否存在,決定是否執行 age 加密。

  • 將最終產生的檔案透過 mv 指令移動到指定的備份目的地。

4. 自動化管理與通知

  • 備份保留策略 (cleanup_old_backups) master-backup.sh 腳本內建了清理舊備份的函數。每次備份成功後,該函數會根據 APP_RETENTIONSYSTEM_RETENTION 變數的值,保留指定數量的最新備份,並刪除較舊的檔案。

  • 執行狀態通知 使用者可選填 /etc/backup-notify.env 檔案中的變數,來啟用 Telegram 或 Email 通知。腳本會在任務執行成功或失敗時發送通知訊息。

使用方法

  1. 執行安裝腳本 透過 sudo bash install-backup-suite.sh 執行安裝程序。腳本提供互動式介面,引導使用者輸入 NAS 位址、憑證、掛載點等設定。

  2. 手動測試:安裝後,可透過以下指令進行功能驗證:

    # 測試應用程式備份流程
    sudo -u backup-runner /usr/local/bin/master-backup.sh app
    
    # 測試通知發送功能
    sudo -u backup-runner /usr/local/sbin/backup-notify-test --both
    
  3. 設定排程任務:可使用 crontabbackup-runner 使用者設定自動化排程。安裝腳本可協助產生一個範例 crontab 項目。

一鍵安裝腳本

#!/usr/bin/env bash
set -euo pipefail

# ======== 可用環境變數覆蓋 ========
BACKUP_USER="${BACKUP_USER:-backup-runner}"
NAS_SERVER_IP="${NAS_SERVER_IP:-10.0.0.5}"
NAS_SHARE_NAME="${NAS_SHARE_NAME:-SharedSSD}"   # 只放 share 名,不要包含子資料夾
NAS_SUBDIR="${NAS_SUBDIR:-}"                    # share 底下的子資料夾(可留空)
NAS_DOMAIN="${NAS_DOMAIN:-WORKGROUP}"          # 工作群組/網域(可留空)
NAS_USERNAME="${NAS_USERNAME:-nasuser}"
NAS_PASSWORD="${NAS_PASSWORD:-naspass}"
LOCAL_MOUNT_POINT="${LOCAL_MOUNT_POINT:-/mnt/nas_backup}"
APP_BACKUP_DIR="${APP_BACKUP_DIR:-${LOCAL_MOUNT_POINT}/app_backups}"
SYSTEM_BACKUP_DIR="${SYSTEM_BACKUP_DIR:-${LOCAL_MOUNT_POINT}/system_backups}"
SYSTEM_DIRS_TO_BACKUP="${SYSTEM_DIRS_TO_BACKUP:-/etc /home /root /srv /var/www /usr/local/bin}"
APP_RETENTION="${APP_RETENTION:-24}"
SYSTEM_RETENTION="${SYSTEM_RETENTION:-7}"
AGE_RECIPIENT="${AGE_RECIPIENT:-}"             # 例如 age1xxxx...;留空=不加密
REQUIRE_SMB_ENCRYPTION="${REQUIRE_SMB_ENCRYPTION:-true}" # 老 NAS 可設為 false
SMB_VERSION_LIST="${SMB_VERSION_LIST:-3.1.1,3.0,2.1,2.0}" # 需要 SMB1 才加 1.0(不建議)
BACKUP_TMP_PARENT="${BACKUP_TMP_PARENT:-/var/tmp/backup-staging}"

# ======== 互動式提示(非必要) ========
ask() {
  local prompt default var
  prompt="$1"; default="$2"; var="$3"
  read -rp "$prompt [$default]: " __ans || true
  eval "$var=\"${__ans:-$default}\""
}
if [[ -t 0 ]]; then
  echo "== 互動式基本設定(Enter 採用預設) =="
  ask "NAS IP" "$NAS_SERVER_IP" NAS_SERVER_IP
  ask "NAS 共享名稱(只填 share 名稱)" "$NAS_SHARE_NAME" NAS_SHARE_NAME
  ask "NAS 子資料夾(可空白,用於 prefixpath)" "$NAS_SUBDIR" NAS_SUBDIR
  ask "NAS 工作群組/網域(可空白)" "$NAS_DOMAIN" NAS_DOMAIN
  ask "NAS 帳號" "$NAS_USERNAME" NAS_USERNAME
  ask "NAS 密碼(寫入 /etc/samba/nas-credentials)" "$NAS_PASSWORD" NAS_PASSWORD
  ask "掛載點" "$LOCAL_MOUNT_POINT" LOCAL_MOUNT_POINT
  ask "APP 備份路徑" "$APP_BACKUP_DIR" APP_BACKUP_DIR
  ask "SYSTEM 備份路徑" "$SYSTEM_BACKUP_DIR" SYSTEM_BACKUP_DIR
  ask "系統備份目錄(空白分隔)" "$SYSTEM_DIRS_TO_BACKUP" SYSTEM_DIRS_TO_BACKUP
  ask "保留 APP 份數" "$APP_RETENTION" APP_RETENTION
  ask "保留 SYSTEM 份數" "$SYSTEM_RETENTION" SYSTEM_RETENTION
  ask "age 公鑰(空白=不加密)" "$AGE_RECIPIENT" AGE_RECIPIENT
  ask "是否強制 SMB 加密(true/false)" "$REQUIRE_SMB_ENCRYPTION" REQUIRE_SMB_ENCRYPTION
  ask "SMB 版本嘗試序列(逗號分隔)" "$SMB_VERSION_LIST" SMB_VERSION_LIST
  ask "暫存根目錄(放臨時壓縮檔)" "$BACKUP_TMP_PARENT" BACKUP_TMP_PARENT
fi

# ======== 前置:root / 套件 ========
if [[ $EUID -ne 0 ]]; then
  echo "請用 root 執行:sudo bash $0"; exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y cifs-utils age curl

# ======== 建使用者(含同名群組) ========
if ! id -u "$BACKUP_USER" >/dev/null 2>&1; then
  useradd --system --user-group --create-home --home-dir "/var/lib/${BACKUP_USER}" \
          --shell /usr/sbin/nologin "$BACKUP_USER"
fi

# ======== 目錄 ========
install -d -m 700 "$LOCAL_MOUNT_POINT"
install -d -m 700 "$(dirname "$APP_BACKUP_DIR")" "$(dirname "$SYSTEM_BACKUP_DIR")" || true
install -d -m 700 "$BACKUP_TMP_PARENT"

# ======== 設定檔 ========
cat >/etc/backup-secure.conf <<EOF
# 安全備份設定
NAS_SERVER_IP="$NAS_SERVER_IP"
NAS_SHARE_NAME="$NAS_SHARE_NAME"
NAS_SUBDIR="$NAS_SUBDIR"
NAS_DOMAIN="$NAS_DOMAIN"
NAS_CREDENTIALS_FILE="/etc/samba/nas-credentials"
LOCAL_MOUNT_POINT="$LOCAL_MOUNT_POINT"
APP_BACKUP_DIR="$APP_BACKUP_DIR"
SYSTEM_BACKUP_DIR="$SYSTEM_BACKUP_DIR"
SYSTEM_DIRS_TO_BACKUP="$SYSTEM_DIRS_TO_BACKUP"
AGE_RECIPIENT="$AGE_RECIPIENT"
REQUIRE_SMB_ENCRYPTION="$REQUIRE_SMB_ENCRYPTION"
SMB_VERSION_LIST="$SMB_VERSION_LIST"
BACKUP_TMP_PARENT="$BACKUP_TMP_PARENT"
EOF
chgrp "$BACKUP_USER" /etc/backup-secure.conf
chmod 640 /etc/backup-secure.conf

# NAS 憑證
cat >/etc/samba/nas-credentials <<EOF
username=$NAS_USERNAME
password=$NAS_PASSWORD
EOF
chmod 600 /etc/samba/nas-credentials

# ======== 包裝指令 ========
cat >/usr/local/sbin/backup-mount <<"EOF"
#!/bin/bash
set -euo pipefail
umask 077

# 載入設定
. /etc/backup-secure.conf

REQUIRE_SMB_ENCRYPTION=${REQUIRE_SMB_ENCRYPTION:-true}
SMB_VERSION_LIST=${SMB_VERSION_LIST:-3.1.1,3.0,2.1}
NAS_SUBDIR=${NAS_SUBDIR:-}
NAS_DOMAIN=${NAS_DOMAIN:-}

# 已掛載就離開
if mountpoint -q "$LOCAL_MOUNT_POINT"; then
  exit 0
fi
install -d -m 700 "$LOCAL_MOUNT_POINT"

mount_try() {
  local v="$1" enc="$2"
  local opts="credentials=${NAS_CREDENTIALS_FILE},vers=${v},nosuid,nodev,noexec,dir_mode=0700,file_mode=0600"
  [ -n "$NAS_DOMAIN" ] && opts="${opts},domain=${NAS_DOMAIN}"
  [ -n "$NAS_SUBDIR" ] && opts="${opts},prefixpath=${NAS_SUBDIR}"
  # 只有 SMB3 支援 seal
  if [ "$enc" = "on" ] && [ "${v%%.*}" = "3" ]; then
    opts="${opts},seal"
  fi
  /usr/sbin/mount.cifs "//${NAS_SERVER_IP}/${NAS_SHARE_NAME}" "$LOCAL_MOUNT_POINT" -o "$opts"
}

# 先試加密
if [ "$REQUIRE_SMB_ENCRYPTION" = "true" ]; then
  for v in ${SMB_VERSION_LIST//,/ }; do
    if mount_try "$v" on 2>/dev/null; then
      echo "Mounted: vers=$v, seal"
      exit 0
    fi
  done
  echo "ERROR: cannot mount with encryption (seal). Enable SMB encryption on NAS or set REQUIRE_SMB_ENCRYPTION=false."
  exit 1
fi

# 再試未加密
for v in ${SMB_VERSION_LIST//,/ }; do
  if mount_try "$v" off 2>/dev/null; then
    echo "Mounted: vers=$v (no encryption)"
    exit 0
  fi
 done

echo "ERROR: all mount attempts failed. Check credentials/share/version list."
exit 1
EOF
chmod 700 /usr/local/sbin/backup-mount

cat >/usr/local/sbin/backup-umount <<"EOF"
#!/bin/bash
set -euo pipefail
. /etc/backup-secure.conf
if mountpoint -q "$LOCAL_MOUNT_POINT"; then
  exec /usr/bin/umount "$LOCAL_MOUNT_POINT"
fi
EOF
chmod 700 /usr/local/sbin/backup-umount

cat >/usr/local/sbin/backup-pack <<"EOF"
#!/bin/bash
# backup-pack: 以固定參數打包並寫入 NAS
#   用法:backup-pack app|system|dir SRC
set -euo pipefail
umask 077
. /etc/backup-secure.conf

MODE="${1:-}"
case "$MODE" in
  app|system|dir) : ;;
  *) echo "usage: $0 {app|system|dir SRC}" >&2; exit 2 ;;
 esac

ts="$(date +%Y%m%d_%H%M%S)"
parent="${BACKUP_TMP_PARENT:-/var/tmp/backup-staging}"
mkdir -p "$parent"; chmod 700 "$parent"
tmpdir="$(mktemp -d -p "$parent" -t bkp.XXXXXX)"
trap 'rm -rf "$tmpdir"' EXIT

# 預先宣告,避免 set -u 下未綁定錯誤
dest_dir=""; name=""

if [[ "$MODE" == "system" ]]; then
  dest_dir="$SYSTEM_BACKUP_DIR"; name="system_backup_${ts}"
elif [[ "$MODE" == "app" ]]; then
  dest_dir="$APP_BACKUP_DIR";    name="app_backup_${ts}"
else  # dir 模式:由外部傳入 SRC,目的地仍為 APP_BACKUP_DIR
  SRC="${2:-}"; [[ -n "$SRC" && -d "$SRC" ]] || { echo "dir 模式需要現有目錄" >&2; exit 2; }
  dest_dir="$APP_BACKUP_DIR";    name="app_backup_${ts}"
fi

tar_args=( -cpf - --numeric-owner --acls --xattrs --xattrs-include='*' )

if [[ "$MODE" == "app" ]]; then
  # 僅在明確使用 app 模式時才參考 APP_DIRS_TO_BACKUP
  if [[ -z "${APP_DIRS_TO_BACKUP:-}" ]]; then
    echo "APP_DIRS_TO_BACKUP 未設定" >&2; exit 2
  fi
  for d in $APP_DIRS_TO_BACKUP; do tar_args+=( -C / "${d#/}" ); done
elif [[ "$MODE" == "system" ]]; then
  tar_args+=( --one-file-system \
              --exclude=/proc --exclude=/sys --exclude=/dev/pts \
              --exclude=/run --exclude="$LOCAL_MOUNT_POINT" \
              --exclude=/tmp --exclude=/var/tmp --exclude=/lost+found \
              / )
else
  tar_args=( -cpf - --numeric-owner --acls --xattrs --xattrs-include='*' -C "$SRC" . )
fi

plain="${tmpdir}/${name}.tar.gz"
enc="${plain}.age"

# 允許 live 系統中文件在打包過程改變(tar rc=1),並抑制該警告訊息
set +o pipefail
tar --warning=no-file-changed "${tar_args[@]}" | gzip -1n > "$plain"
tar_rc=${PIPESTATUS[0]}
set -o pipefail
if [[ $tar_rc -gt 1 ]]; then
  echo "tar failed (rc=$tar_rc)" >&2
  exit $tar_rc
fi

if [[ -n "${AGE_RECIPIENT:-}" ]]; then
  age -r "$AGE_RECIPIENT" -o "$enc" "$plain"
  rm -f "$plain"
  final="${dest_dir}/${name}.tar.gz.age"; src="$enc"
else
  final="${dest_dir}/${name}.tar.gz";     src="$plain"
fi

install -d -m 700 "$dest_dir"
mv -f "$src" "$final"
sync -f "$(dirname "$final")" 2>/dev/null || true
echo "$final"
EOF
chmod 700 /usr/local/sbin/backup-pack

cat >/usr/local/sbin/backup-app-dumps <<"EOF"
#!/bin/bash
set -euo pipefail
umask 077
. /etc/backup-secure.conf

parent="${BACKUP_TMP_PARENT:-/var/tmp/backup-staging}"
mkdir -p "$parent"; chmod 700 "$parent"
tmpdir="$(mktemp -d -p "$parent" -t appdump.XXXXXX)"
trap 'rm -rf "$tmpdir"' EXIT

echo " - Gitea(若存在)"
if command -v /usr/local/bin/gitea >/dev/null 2>&1 && id -u git >/dev/null 2>&1; then
  install -d -m 700 "$tmpdir/gitea_tmp"
  sudo -u git /usr/local/bin/gitea dump -c /etc/gitea/app.ini --tempdir "$tmpdir/gitea_tmp" -f "$tmpdir/gitea_backup.zip" || true
fi

echo " - PostgreSQL(若存在)"
if command -v pg_dumpall >/dev/null 2>&1 && id -u postgres >/dev/null 2>&1; then
  sudo -u postgres /usr/bin/pg_dumpall | gzip > "$tmpdir/postgresql_all.sql.gz" || true
fi

echo " - MariaDB/MySQL(若存在)"
if command -v mysqldump >/dev/null 2>&1; then
  /usr/bin/mysqldump --all-databases | gzip > "$tmpdir/mariadb_all.sql.gz" || true
fi

exec /usr/local/sbin/backup-pack dir "$tmpdir"
EOF
chmod 700 /usr/local/sbin/backup-app-dumps

# ======== sudoers(精準白名單) ========
SUDOERS_FILE="/etc/sudoers.d/${BACKUP_USER}-backup"
cat >"$SUDOERS_FILE" <<EOF
Defaults:${BACKUP_USER} secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

User_Alias  BACKUPU = ${BACKUP_USER}
Cmnd_Alias  BKP_CMDS = /usr/local/sbin/backup-mount, /usr/local/sbin/backup-umount, \
                       /usr/local/sbin/backup-pack app, /usr/local/sbin/backup-pack system, \
                       /usr/local/sbin/backup-pack dir *, /usr/local/sbin/backup-app-dumps

BACKUPU ALL=(root) NOPASSWD: BKP_CMDS
EOF
chmod 440 "$SUDOERS_FILE"
visudo -cf "$SUDOERS_FILE" >/dev/null || { echo "sudoers 驗證失敗"; exit 1; }

# ======== 強化版主腳本 ========
cat >/usr/local/bin/master-backup.sh <<"EOF"
#!/bin/bash
set -euo pipefail
[ -f /etc/backup-secure.conf ] && . /etc/backup-secure.conf
[ -f /etc/backup-notify.env ] && . /etc/backup-notify.env

HOSTNAME_TAG="$(hostname)"
APP_RETENTION="${APP_RETENTION:-24}"
SYSTEM_RETENTION="${SYSTEM_RETENTION:-7}"

TELEGRAM_TOKEN="${TELEGRAM_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
EMAIL_RECIPIENT="${EMAIL_RECIPIENT:-}"
NOTIFY_ON_SUCCESS="${NOTIFY_ON_SUCCESS:-true}"

send_notification() {
  local status_emoji="$1" title="$2" message="$3"
  local full_message="${status_emoji} [${HOSTNAME_TAG}] ${title}\n\n${message}"
  if command -v curl >/dev/null 2>&1 && [[ -n "$TELEGRAM_TOKEN" && -n "$TELEGRAM_CHAT_ID" ]]; then
    curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
      -d chat_id="${TELEGRAM_CHAT_ID}" -d text="${full_message}" >/dev/null || true
  fi
  if command -v ssmtp >/dev/null 2>&1; then
    if [[ -n "$EMAIL_RECIPIENT" ]]; then
      echo -e "Subject: ${status_emoji} [${HOSTNAME_TAG}] ${title}\n\n${message}" | ssmtp "$EMAIL_RECIPIENT" || true
    fi
  elif command -v msmtp >/dev/null 2>&1; then
    if [[ -n "$EMAIL_RECIPIENT" ]]; then
      printf "To: %s\nSubject: %s\n\n%s\n" "$EMAIL_RECIPIENT" "通知:${title}" "${full_message}" | msmtp -t || true
    fi
  fi
}

mount_nas()   { echo "掛載 NAS...";   sudo /usr/local/sbin/backup-mount; }
unmount_nas() { echo "卸載 NAS...";   sudo /usr/local/sbin/backup-umount; }

backup_applications() {
  echo "正在備份應用程式..."
  local out
  out="$(sudo /usr/local/sbin/backup-app-dumps)"
  echo "APP 備份完成: $out"
}

backup_system() {
  echo "正在備份系統..."
  local out
  out="$(sudo /usr/local/sbin/backup-pack system)"
  echo "SYSTEM 備份完成: $out"
}

cleanup_old_backups() {
  if [[ -d "${APP_BACKUP_DIR:-}" ]]; then
    echo "清理舊的應用程式備份 (保留 ${APP_RETENTION} 份)..."
    find "$APP_BACKUP_DIR" -maxdepth 1 -type f -name 'app_backup_*.tar.gz*' -printf '%T@ %p\0' \
      | sort -z -nr | tail -z -n +$((APP_RETENTION + 1)) \
      | cut -z -d ' ' -f2- | xargs -0r rm -f --
  fi
  if [[ -d "${SYSTEM_BACKUP_DIR:-}" ]]; then
    echo "清理舊的系統備份 (保留 ${SYSTEM_RETENTION} 份)..."
    find "$SYSTEM_BACKUP_DIR" -maxdepth 1 -type f -name 'system_backup_*.tar.gz*' -printf '%T@ %p\0' \
      | sort -z -nr | tail -z -n +$((SYSTEM_RETENTION + 1)) \
      | cut -z -d ' ' -f2- | xargs -0r rm -f --
  fi
}

main() {
  local BACKUP_TYPE="${1:-}"
  [[ -n "$BACKUP_TYPE" ]] || { echo "用法:$0 {app|system}" >&2; exit 2; }
  local START_TIME; START_TIME="$(date +%s)"

  handle_failure() {
    unmount_nas
    send_notification "❌" "備份失敗: ${BACKUP_TYPE}" "腳本執行過程中發生錯誤或被中斷。"
    exit 1
  }
  trap 'handle_failure' ERR INT TERM
  trap 'unmount_nas' EXIT

  mount_nas
  case "$BACKUP_TYPE" in
    app)    backup_applications ;;
    system) backup_system ;;
    *) echo "未知的備份模式 '${BACKUP_TYPE}'。" >&2; exit 1 ;;
  esac
  cleanup_old_backups

  local END_TIME; END_TIME="$(date +%s)"
  local DURATION=$((END_TIME - START_TIME))
  if [[ "${NOTIFY_ON_SUCCESS}" == "true" ]]; then
    send_notification "✅" "備份成功: ${BACKUP_TYPE}" "備份任務已成功完成。\n耗時: ${DURATION} 秒。"
  fi
}
main "$@"
EOF
chown root:"$BACKUP_USER" /usr/local/bin/master-backup.sh
chmod 750 /usr/local/bin/master-backup.sh

# ======== 通知 env(空白模板) ========
if [[ ! -f /etc/backup-notify.env ]]; then
  cat >/etc/backup-notify.env <<'EOF'
# 這個檔案若存在會被 master-backup.sh 載入
# TELEGRAM_TOKEN="your_bot_token"
# TELEGRAM_CHAT_ID="your_chat_id"
# EMAIL_RECIPIENT="[email protected]"
# NOTIFY_ON_SUCCESS=true
EOF
fi
chgrp "$BACKUP_USER" /etc/backup-notify.env
chmod 640 /etc/backup-notify.env

# ======== 通知測試工具 ========
cat >/usr/local/sbin/backup-notify-test <<"EOF"
#!/bin/bash
set -euo pipefail
notify_env="/etc/backup-notify.env"
[[ -r "$notify_env" ]] && . "$notify_env"
TELEGRAM_TOKEN="${TELEGRAM_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
EMAIL_RECIPIENT="${EMAIL_RECIPIENT:-}"
HOSTNAME_TAG="$(hostname)"
send_t=false; send_e=false
msg="這是一則測試通知(主機:${HOSTNAME_TAG})於 $(date -Is)"
usage(){ echo "用法:$0 [--telegram] [--email] [--both] [--message <文字>]"; exit 2; }
while [[ $# -gt 0 ]]; do
  case "$1" in
    --telegram) send_t=true ;;
    --email)    send_e=true ;;
    --both)     send_t=true; send_e=true ;;
    --message)  shift; [[ $# -gt 0 ]] || usage; msg="$1" ;;
    -h|--help)  usage ;;
    *) echo "未知參數:$1"; usage ;;
  esac; shift
done
if ! $send_t && ! $send_e; then send_t=true; send_e=true; fi
rc=0
if $send_t; then
  if ! command -v curl >/dev/null 2>&1; then echo "❌ 缺少 curl"; rc=1
  elif [[ -z "$TELEGRAM_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then echo "❌ TELEGRAM_TOKEN/CHAT_ID 未設定"; rc=1
  else curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" -d chat_id="${TELEGRAM_CHAT_ID}" -d text="${msg}" >/dev/null && echo "✅ Telegram 已送出" || { echo "❌ Telegram 送出失敗"; rc=1; }
  fi
fi
if $send_e; then
  if [[ -n "$EMAIL_RECIPIENT" ]]; then
    if command -v ssmtp >/dev/null 2>&1; then printf "Subject: 測試通知(%s)\n\n%s\n" "$HOSTNAME_TAG" "$msg" | ssmtp "$EMAIL_RECIPIENT" && echo "✅ Email 已透過 sSMTP 送出" || { echo "❌ Email(sSMTP)失敗"; rc=1; }
    elif command -v msmtp >/dev/null 2>&1; then printf "To: %s\nSubject: 測試通知(%s)\n\n%s\n" "$EMAIL_RECIPIENT" "$HOSTNAME_TAG" "$msg" | msmtp -t && echo "✅ Email 已透過 msmtp 送出" || { echo "❌ Email(msmtp)失敗"; rc=1; }
    else echo "❌ 找不到 ssmtp 或 msmtp"; rc=1; fi
  else echo "❌ EMAIL_RECIPIENT 未設定"; rc=1; fi
fi
exit $rc
EOF
chown root:"$BACKUP_USER" /usr/local/sbin/backup-notify-test
chmod 750 /usr/local/sbin/backup-notify-test

# ======== (可選)建立 cron ========
if [[ -t 0 ]]; then
  read -rp "要建立 cron 範例嗎?(y/N) " YN || true
  if [[ "${YN,,}" == "y" ]]; then
    tmpcron="$(mktemp)"
    crontab -u "$BACKUP_USER" -l 2>/dev/null | sed '/master-backup.sh/d' >"$tmpcron" || true
    {
      echo "## backup suite"
      echo "0 3 * * * /usr/local/bin/master-backup.sh app    >>/var/log/master-backup.log 2>&1"
      echo "30 4 * * 0 /usr/local/bin/master-backup.sh system >>/var/log/master-backup.log 2>&1"
    } >>"$tmpcron"
    crontab -u "$BACKUP_USER" "$tmpcron"
    rm -f "$tmpcron"
  fi
fi

echo
echo "=== 完成!檢查 sudo 權限 ==="
sudo -l -U "$BACKUP_USER" || true
echo "試跑:"
echo "  sudo -u $BACKUP_USER /usr/local/bin/master-backup.sh app"
echo "  sudo -u $BACKUP_USER /usr/local/bin/master-backup.sh system"
echo "  sudo -u $BACKUP_USER /usr/local/sbin/backup-notify-test --both --message 'Hello from $(hostname)'"